HEX
Server: Apache/2.4.68 (Debian)
System: Linux as-cs-widget-demo-us-central1 6.1.0-44-cloud-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.164-1 (2026-03-09) x86_64
User: root (0)
PHP: 8.2.32
Disabled: NONE
Upload Files
File: /var/www/kevin-demo/wp-content/plugins/allspice/includes/content-gates.php
<?php
// includes/content-gates.php
//
// Whole-post/page content-access sync (gate types: content_immediate, content_preview).
//
// Smallest possible sync against GET /loadContentAccessForWPSync (same partner/domain/token
// contract as the other WP sync endpoints). The response is a COMPLETE authoritative
// snapshot: a successful sync atomically replaces the stored rules (absent items become
// public; an empty snapshot empties the rules), while ANY failure preserves the previous
// snapshot untouched. recipe_card gating is a different system (recipe sync +
// membership-gate.php) and is ignored here on purpose.
//
// Storage: ONE non-autoloaded option - no custom tables, no post meta, and nothing about the
// articles themselves (no titles/bodies/categories/remote ids), only url_key -> access rule.

if (!defined('ABSPATH')) exit;

const ALLSPICE_CONTENT_GATES_OPTION = 'allspice_content_gates_v1';

function allspice_content_access_endpoint(): string {
    $path = '/loadContentAccessForWPSync';
    return apply_filters('allspice_content_access_endpoint', allspice_webhook_base() . $path);
}

/*
 * Lookup keys must match on both sides. Remote url_keys arrive host-first without a scheme
 * ("example.com/member-article/"); local keys come from get_permalink(). Both funnel through
 * allspice_url_key() (the recipe/page normalizer: strips scheme, www., trailing slashes,
 * index.html) so one rule produces one representation.
 */
function allspice_content_gate_key($raw): string {
    $raw = trim((string)$raw);
    if ($raw === '') return '';
    if (strpos($raw, '://') === false) $raw = 'https://' . $raw;
    return allspice_url_key($raw);
}

/*
 * Normalize the snapshot items to url_key => ['gate_type' => ..., 'product_ids' => [...]].
 * Rules: url_key required; gated entries must be dictionaries; only content_immediate /
 * content_preview count (recipe_card and anything else ignored); product_id required,
 * trimmed, deduplicated; multiple ids mean OR access. Mixed types on one URL: presentation
 * uses content_immediate (it strictly dominates a preview), ALL valid ids stay as OR access,
 * and a debug warning is emitted - the Portal normally sends one type per URL.
 */
function allspice_content_gates_normalize($items): array {
    $out = [];
    if (!is_array($items)) return $out;
    foreach ($items as $item) {
        if (!is_array($item)) continue;
        $key = allspice_content_gate_key($item['url_key'] ?? '');
        if ($key === '') continue;
        $types = [];
        $ids = [];
        $gated = isset($item['gated']) && is_array($item['gated']) ? $item['gated'] : [];
        foreach ($gated as $g) {
            if (!is_array($g)) continue;
            $type = trim((string)($g['gate_type'] ?? ''));
            if ($type !== 'content_immediate' && $type !== 'content_preview') continue;
            $pid = trim((string)($g['product_id'] ?? ''));
            if ($pid === '') continue;
            $types[$type] = true;
            $ids[$pid] = true;
        }
        if ($ids === [] || $types === []) continue;
        if (count($types) > 1 && function_exists('allspice_console_log')) {
            allspice_console_log('[Allspice] content gate WARNING: mixed gate types for one URL; content_immediate wins', [
                'url_key' => $key,
                'types' => array_keys($types),
            ]);
        }
        $out[$key] = [
            'gate_type' => isset($types['content_immediate']) ? 'content_immediate' : 'content_preview',
            'product_ids' => array_keys($ids),
        ];
    }
    ksort($out);
    return $out;
}

/*
 * Fetch + store. NEVER called on ordinary frontend page loads - only from the cron sync and
 * the admin manual-sync flow. Failures of any kind preserve the existing snapshot.
 */
function allspice_content_gates_sync_failed(string $error): array {
    if (function_exists('allspice_opt_update')) {
        allspice_opt_update(['content_gates_last_error' => $error]);
    }
    return ['ok' => false, 'changed' => false, 'count' => 0, 'error' => $error];
}

function allspice_refresh_content_gates(bool $force = false): array {
    $s = allspice_opt_get();
    $partner_id = trim((string)$s['partner_id']);
    $domain_id = trim((string)$s['domain_id']);
    $token = trim((string)$s['webhook_token']);
    if ($partner_id === '' || $domain_id === '' || $token === '') {
        return allspice_content_gates_sync_failed('Missing partner_id, domain_id, or webhook_token');
    }
    $url = add_query_arg(['partner_id' => $partner_id, 'domain_id' => $domain_id], allspice_content_access_endpoint());
    $headers = [
        'Accept' => 'application/json',
        'Authorization' => 'Bearer ' . $token,
        'X-Allspice-Partner-Id' => $partner_id,
        'X-Allspice-Domain-Id' => $domain_id,
        'Cache-Control' => 'no-cache',
        'Pragma' => 'no-cache',
    ];
    $resp = wp_remote_get($url, ['timeout' => 15, 'headers' => $headers]);
    if (is_wp_error($resp)) {
        return allspice_content_gates_sync_failed(trim((string)$resp->get_error_message()) ?: 'WP_Error');
    }
    $code = (int)wp_remote_retrieve_response_code($resp);
    $body = (string)wp_remote_retrieve_body($resp);
    if ($code < 200 || $code >= 300) {
        return allspice_content_gates_sync_failed('HTTP ' . $code . ': ' . substr($body, 0, 200));
    }
    $json = json_decode($body, true);
    if (!is_array($json) || ($json['ok'] ?? null) === false) {
        return allspice_content_gates_sync_failed('Invalid JSON or ok=false');
    }
    if ((int)($json['schema_version'] ?? 0) !== 1) {
        return allspice_content_gates_sync_failed('Unsupported schema_version');
    }
    if (($json['full_snapshot'] ?? null) !== true) {
        return allspice_content_gates_sync_failed('Not a full snapshot');
    }
    if (trim((string)($json['partner_id'] ?? '')) !== $partner_id) {
        return allspice_content_gates_sync_failed('partner_id mismatch');
    }
    if (trim((string)($json['domain_id'] ?? '')) !== $domain_id) {
        return allspice_content_gates_sync_failed('domain_id mismatch');
    }
    if (!array_key_exists('items', $json) || !is_array($json['items'])) {
        return allspice_content_gates_sync_failed('Missing items array');
    }
    if ((int)($json['count'] ?? -1) !== count($json['items'])) {
        return allspice_content_gates_sync_failed('count does not match items');
    }

    if (function_exists('allspice_opt_update')) {
        allspice_opt_update(['content_gates_last_error' => '']);
    }
    $gates = allspice_content_gates_normalize($json['items']);
    $hash = sha1((string)wp_json_encode($gates));

    $existing = get_option(ALLSPICE_CONTENT_GATES_OPTION);
    if (is_array($existing) && (string)($existing['hash'] ?? '') === $hash) {
        /* Unchanged normalized snapshot: no write (item: same hash avoids the write). */
        if (function_exists('allspice_opt_update')) {
            allspice_opt_update(['content_gates_synced_at' => time(), 'content_gates_last_error' => '']);
        }
        return ['ok' => true, 'changed' => false, 'count' => count($gates), 'error' => ''];
    }

    /* Atomic authoritative replace: absent items become public; empty snapshot empties rules. */
    if (function_exists('allspice_opt_update')) {
        allspice_opt_update(['content_gates_synced_at' => time(), 'content_gates_last_error' => '']);
    }
    update_option(ALLSPICE_CONTENT_GATES_OPTION, [
        'schema' => 1,
        'gates' => $gates,
        'hash' => $hash,
        'response_hash' => (string)($json['hash'] ?? ''),
        'synced_at' => time(),
    ], false /* never autoloaded */);
    allspice_content_gates_reset_request_cache();

    return ['ok' => true, 'changed' => true, 'count' => count($gates), 'error' => ''];
}

/* ------------------------------------------------------------------------------- lookups */

function allspice_content_gates_reset_request_cache(): void {
    $GLOBALS['allspice_content_gates_cache'] = null;
    $GLOBALS['allspice_content_gate_lookups'] = [];
}

/* Full normalized map, cached for the current PHP request only. */
function allspice_content_gates_get(): array {
    if (isset($GLOBALS['allspice_content_gates_cache']) && is_array($GLOBALS['allspice_content_gates_cache'])) {
        return $GLOBALS['allspice_content_gates_cache'];
    }
    $stored = get_option(ALLSPICE_CONTENT_GATES_OPTION);
    $gates = (is_array($stored) && isset($stored['gates']) && is_array($stored['gates'])) ? $stored['gates'] : [];
    $GLOBALS['allspice_content_gates_cache'] = $gates;
    return $gates;
}

/* Rule for one url_key, or null. Per-key result cached for the request. */
function allspice_content_gate_for_url_key($url_key) {
    $key = allspice_content_gate_key($url_key);
    if ($key === '') return null;
    if (isset($GLOBALS['allspice_content_gate_lookups']) && array_key_exists($key, $GLOBALS['allspice_content_gate_lookups'])) {
        return $GLOBALS['allspice_content_gate_lookups'][$key];
    }
    $gates = allspice_content_gates_get();
    $rule = isset($gates[$key]) && is_array($gates[$key]) ? $gates[$key] : null;
    if (!isset($GLOBALS['allspice_content_gate_lookups']) || !is_array($GLOBALS['allspice_content_gate_lookups'])) {
        $GLOBALS['allspice_content_gate_lookups'] = [];
    }
    $GLOBALS['allspice_content_gate_lookups'][$key] = $rule;
    return $rule;
}

/*
 * Exclusions: the locally generated membership landing page is NEVER gated (identified by
 * the allspice_membership_page_id filter/setting once that feature lands - 0 = none yet),
 * and developers can exclude further post ids/urls via allspice_content_gate_excluded.
 */
function allspice_content_gate_is_excluded(int $post_id, string $url_key = ''): bool {
    $landing_id = (int)apply_filters('allspice_membership_page_id', (int)get_option('allspice_membership_page_id', 0));
    if ($landing_id > 0 && $post_id === $landing_id) return true;
    return (bool)apply_filters('allspice_content_gate_excluded', false, $post_id, $url_key);
}

/* Rule for a WordPress post/page: permalink -> url_key -> lookup. WP post ids are NEVER the
   remote identity - they only resolve to the permalink locally. */
function allspice_content_gate_for_post($post_id) {
    $post_id = (int)$post_id;
    if ($post_id <= 0) return null;
    $permalink = get_permalink($post_id);
    if (!is_string($permalink) || $permalink === '') return null;
    $key = allspice_content_gate_key($permalink);
    if ($key === '' || allspice_content_gate_is_excluded($post_id, $key)) return null;
    return allspice_content_gate_for_url_key($key);
}

/* Rule for the current main-query singular object, or null. */
function allspice_current_content_gate() {
    if (!is_singular()) return null;
    $post_id = (int)get_queried_object_id();
    if ($post_id <= 0) return null;
    return allspice_content_gate_for_post($post_id);
}